{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "tags": [
     "parameters"
    ]
   },
   "outputs": [],
   "source": [
    "epochs = 10\n",
    "# No usamos todos los datos para ser más eficientes pero siéntete libre de aumentar estos números\n",
    "n_train_items = 640\n",
    "n_test_items = 640"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Parte 12 bis - Entrenamiento Seguro y Evaluación con MNIST\n",
    "\n",
    "Cuando construimos Soluciones de Aprendizaje Automático como un Servicio (MLaaS), la compañía puede pedir acceso a los datos desde otros socios para entrenar su modelo. En salud o finanzas, el modelo y los datos son críticos: los parámetros del modelo son un activo comercial mientras que los datos personales son algo estrictamente regulado. \n",
    "\n",
    "En este contexto, una solución posible es encriptar ambos el modelo y los datos para entrenar el modelo de aprendizaje automático con los valores encriptados. Esto garantiza que por ejemplo, la compañía no acceda a los registros médicos de los pacientes y que las instalaciones médicas no puedan observar el modelo al que contribuyen. Existen varios esquemas que permiten computaciones sobre datos encriptados, la Computación Segura Multiparte (SMPC), el Encriptado Homomórfico (FHE/SHE) y el Encriptado Funcional (FE). Nos enfocaremos en la Computación Multiparte (introducida en el tutorial 5) la cual consiste en uso compartido de aditivos privados y se basa en los protocolos de cifrado SecureNN y SPDZ.\n",
    "\n",
    "La configuración exacta de este tutorial es la siguiente: considera que eres el único servidor y te gustaría entrenar tu modelo con datos en poder de $n$ trabajadores. El servidor comparte el secreto del modelo y manda cada parte del secreto a un trabajador. Los trabajadores también comparten el secreto de sus datos y lo intercambian entre ellos. En la configuración que estudiaremos hay 2 trabajadores: Alice y Bob. Después de intercambiar secretos, cada uno ahora tiene su propia parte, una parte del otro trabajador y una parte del modelo. Ahora la computación puede empezar entrenando privadamente el modelo usando los protocolos criptográficos apropiados. Una vez que el modelo es entrenado, todas las partes pueden ser regresadas al servidor para que las descifre. Esto es ilustrado con la siguiente figura:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "![SMPC Illustration](https://github.com/OpenMined/PySyft/raw/11c85a121a1a136e354945686622ab3731246084/examples/tutorials/material/smpc_illustration.png)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Para dar un ejemplo de este proceso, asumamos que Alice y Bob tienen ambos una parte del conjuto de datos MNIST y entrenemos un modelo para hacer clasificación de digitos.\n",
    "\n",
    "Autores:\n",
    "- Théo Ryffel - Twitter [@theoryffel](https://twitter.com/theoryffel) - GitHub: [@LaRiffle](https://github.com/LaRiffle)\n",
    "- Bobby Wagner - Twitter [@bobbyawagner](https://twitter.com/bobbyawagner) - GitHub: [@robert-wagner](https://github.com/robert-wagner)\n",
    "- Marianne Monteiro - Twitter [@hereismari](https://twitter.com/hereismari) - GitHub: [@mari-linhares](https://github.com/mari-linhares)\t\n",
    "\n",
    "Traducción:\n",
    "- Arturo Márquez Flores - Twitter: [@arturomf94](https://twitter.com/arturomf94) \n",
    "- Ricardo Pretelt - Twitter: [@ricardopretelt](https://twitter.com/ricardopretelt)\n",
    "- Carlos Salgado - Github: [@socd06](https://github.com/socd06) \n",
    "- Daniel Firebanks-Quevedo - GitHub: [@thefirebanks](https://www.github.com/thefirebanks)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 1. Demostración de Entrenamiento Encriptado con MNIST"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Importaciones y configuración del entrenamiento"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import torch\n",
    "import torch.nn as nn\n",
    "import torch.nn.functional as F\n",
    "import torch.optim as optim\n",
    "from torchvision import datasets, transforms\n",
    "\n",
    "import time"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esta clase describe todos los hiperparámetros para el entrenamiento. Nota que aquí todos son públicos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Arguments():\n",
    "    def __init__(self):\n",
    "        self.batch_size = 64\n",
    "        self.test_batch_size = 64\n",
    "        self.epochs = epochs\n",
    "        self.lr = 0.02\n",
    "        self.seed = 1\n",
    "        self.log_interval = 1 # Log info at each batch\n",
    "        self.precision_fractional = 3\n",
    "\n",
    "args = Arguments()\n",
    "\n",
    "_ = torch.manual_seed(args.seed)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aquí están las importaciones de PySyft. Nos conectamos a dos trabajadores remotos que llamaremos `alice` y `bob` y pedimos otro trabajador llamado `crypto_provider` que da todos los primitivos cripto que necesitamos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import syft as sy  # importar la librería Pysyft \n",
    "hook = sy.TorchHook(torch)  # enganchar PyTorch para agregar funcionalidad extra como el Aprendizaje Federado y el Aprendizaje Encriptado\n",
    "\n",
    "# funciones de simulación \n",
    "def connect_to_workers(n_workers):\n",
    "    return [\n",
    "        sy.VirtualWorker(hook, id=f\"worker{i+1}\")\n",
    "        for i in range(n_workers)\n",
    "    ]\n",
    "def connect_to_crypto_provider():\n",
    "    return sy.VirtualWorker(hook, id=\"crypto_provider\")\n",
    "\n",
    "workers = connect_to_workers(n_workers=2)\n",
    "crypto_provider = connect_to_crypto_provider()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Conseguir acceso y compartir el secreto de los datos\n",
    "\n",
    "Aquí usamos una función utilitaria que simula el siguiente comportamiento: asumimos que el conjunto de datos MNIST está distribuído en partes las cuales están en poder de uno de nuestros trabajadores. Los trabajadores entonces dividen sus datos en grupos y comparten el secreto de sus datos entre ellos. El objeto final regresado es un iterable sobre los grupos de secretos partidos, que llamamos **private data loader**. Nota que durante el proceso el trabajador local (nosotros) nunca tuvo acceso a los datos.\n",
    "\n",
    "Como usualmente, obtenemos un conjunto de datos de entrenamiento y prueba privado, y se comparten los secretos de ambas las entradas y las etiquetas."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def get_private_data_loaders(precision_fractional, workers, crypto_provider):\n",
    "    \n",
    "    def one_hot_of(index_tensor):\n",
    "        \"\"\"\n",
    "        Transform to one hot tensor\n",
    "        \n",
    "        Example:\n",
    "            [0, 3, 9]\n",
    "            =>\n",
    "            [[1., 0., 0., 0., 0., 0., 0., 0., 0., 0.],\n",
    "             [0., 0., 0., 1., 0., 0., 0., 0., 0., 0.],\n",
    "             [0., 0., 0., 0., 0., 0., 0., 0., 0., 1.]]\n",
    "            \n",
    "        \"\"\"\n",
    "        onehot_tensor = torch.zeros(*index_tensor.shape, 10) # 10 clases para MNIST\n",
    "        onehot_tensor = onehot_tensor.scatter(1, index_tensor.view(-1, 1), 1)\n",
    "        return onehot_tensor\n",
    "        \n",
    "    def secret_share(tensor):\n",
    "        \"\"\"\n",
    "        Transform to fixed precision and secret share a tensor\n",
    "        \"\"\"\n",
    "        return (\n",
    "            tensor\n",
    "            .fix_precision(precision_fractional=precision_fractional)\n",
    "            .share(*workers, crypto_provider=crypto_provider, requires_grad=True)\n",
    "        )\n",
    "    \n",
    "    transformation = transforms.Compose([\n",
    "        transforms.ToTensor(),\n",
    "        transforms.Normalize((0.1307,), (0.3081,))\n",
    "    ])\n",
    "    \n",
    "    train_loader = torch.utils.data.DataLoader(\n",
    "        datasets.MNIST('../data', train=True, download=True, transform=transformation),\n",
    "        batch_size=args.batch_size\n",
    "    )\n",
    "    \n",
    "    private_train_loader = [\n",
    "        (secret_share(data), secret_share(one_hot_of(target)))\n",
    "        for i, (data, target) in enumerate(train_loader)\n",
    "        if i < n_train_items / args.batch_size\n",
    "    ]\n",
    "    \n",
    "    test_loader = torch.utils.data.DataLoader(\n",
    "        datasets.MNIST('../data', train=False, download=True, transform=transformation),\n",
    "        batch_size=args.test_batch_size\n",
    "    )\n",
    "    \n",
    "    private_test_loader = [\n",
    "        (secret_share(data), secret_share(target.float()))\n",
    "        for i, (data, target) in enumerate(test_loader)\n",
    "        if i < n_test_items / args.test_batch_size\n",
    "    ]\n",
    "    \n",
    "    return private_train_loader, private_test_loader\n",
    "    \n",
    "    \n",
    "private_train_loader, private_test_loader = get_private_data_loaders(\n",
    "    precision_fractional=args.precision_fractional,\n",
    "    workers=workers,\n",
    "    crypto_provider=crypto_provider\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Especificación del Modelo \n",
    "\n",
    "Aquí está el modelo que usaremos, es uno bastante sencillo pero [ha demostrado un rendimiento razonablemente bueno en MNIST] (https://towardsdatascience.com/handwritten-digit-mnist-pytorch-977b5338e627)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Net(nn.Module):\n",
    "    def __init__(self):\n",
    "        super(Net, self).__init__()\n",
    "        self.fc1 = nn.Linear(28 * 28, 128)\n",
    "        self.fc2 = nn.Linear(128, 64)\n",
    "        self.fc3 = nn.Linear(64, 10)\n",
    "\n",
    "    def forward(self, x):\n",
    "        x = x.view(-1, 28 * 28)\n",
    "        x = F.relu(self.fc1(x))\n",
    "        x = F.relu(self.fc2(x))\n",
    "        x = self.fc3(x)\n",
    "        return x"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Funciones de Entrenamiento y Prueba\n",
    "\n",
    "El entrenamiento se hace casi como de costumbre, la verdadera diferencia es que no podemos usar las pérdidas como probabilidad de registro negativa (`F.nll_loss` en PyTorch) porque es bastante complicado reproducir estas funciones en SMPC. En cambio, usamos una pérdida más simple por Error Cuadrático Medio (MSE)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def train(args, model, private_train_loader, optimizer, epoch):\n",
    "    model.train()\n",
    "    for batch_idx, (data, target) in enumerate(private_train_loader): # <-- ahora son datos privados\n",
    "        start_time = time.time()\n",
    "        \n",
    "        optimizer.zero_grad()\n",
    "        \n",
    "        output = model(data)\n",
    "        \n",
    "        # loss = F.nll_loss(output, target)  <-- aquí no es posible\n",
    "        batch_size = output.shape[0]\n",
    "        loss = ((output - target)**2).sum().refresh()/batch_size\n",
    "        \n",
    "        loss.backward()\n",
    "        \n",
    "        optimizer.step()\n",
    "\n",
    "        if batch_idx % args.log_interval == 0:\n",
    "            loss = loss.get().float_precision()\n",
    "            print('Train Epoch: {} [{}/{} ({:.0f}%)]\\tLoss: {:.6f}\\tTime: {:.3f}s'.format(\n",
    "                epoch, batch_idx * args.batch_size, len(private_train_loader) * args.batch_size,\n",
    "                100. * batch_idx / len(private_train_loader), loss.item(), time.time() - start_time))\n",
    "            "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "¡La función para probar no cambia!"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def test(args, model, private_test_loader):\n",
    "    model.eval()\n",
    "    test_loss = 0\n",
    "    correct = 0\n",
    "    with torch.no_grad():\n",
    "        for data, target in private_test_loader:\n",
    "            start_time = time.time()\n",
    "            \n",
    "            output = model(data)\n",
    "            pred = output.argmax(dim=1)\n",
    "            correct += pred.eq(target.view_as(pred)).sum()\n",
    "\n",
    "    correct = correct.get().float_precision()\n",
    "    print('\\nTest set: Accuracy: {}/{} ({:.0f}%)\\n'.format(\n",
    "        correct.item(), len(private_test_loader)* args.test_batch_size,\n",
    "        100. * correct.item() / (len(private_test_loader) * args.test_batch_size)))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### ¡Corramos el entrenamiento!\n",
    "\n",
    "Unas cuantas notas sobre lo que está pasando aquí. Primero, compartimos el secreto de todos los parámetros del modelo con nuestros trabajadores. Segundo, convertimos los hiperparámetros del optimizador a precisión fija. Nota que no necesitamos compartir el secreto de ellos porque son públicos en nuestro contexto, pero como los valores secretos compartidos viven en campos finitos todavía necesitamos moverlos en los campos finitos usando `.fix_precision` para ejecutar consistentemente operaciones como la actualización de los pesos $W \\leftarrow W - \\alpha * \\Delta W$."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "model = Net()\n",
    "model = model.fix_precision().share(*workers, crypto_provider=crypto_provider, requires_grad=True)\n",
    "\n",
    "optimizer = optim.SGD(model.parameters(), lr=args.lr)\n",
    "optimizer = optimizer.fix_precision() \n",
    "\n",
    "for epoch in range(1, args.epochs + 1):\n",
    "    train(args, model, private_train_loader, optimizer, epoch)\n",
    "    test(args, model, private_test_loader)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "¡Ahí lo tienes! 75% de precisión usando solo una pequeña fracción del conjunto de datos MNIST, empleando el 100% de entrenamiento encriptado."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 2. Discusión\n",
    "\n",
    "Tomemos un vistazo más de cerca al poder del entrenamiento encriptado analizando lo que acabamos de hacer."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2.1 Tiempo de Computación\n",
    "\n",
    "Lo primero es obviamente el tiempo de ejecución. Como seguramente te has dado cuenta, es mucho más lento que el entrenamiento regular solo de texto. En particular, una iteración sobre 1 grupo de 64 elementos toma 3.2s mientras que solo 13ms solamente usando PyTorch. Mientras que esto podría verse como un bloqueador, solo recuerda que aquí todo pasó remotamente y en el mundo encriptado: ni un solo dato fué revelado. Más específicamente, el tiempo para procesar un elemento es 50ms que no está tan mal. La verdadera pregunta es analizar cuando el entrenamiento encriptado es necesario y cuando la predicción encriptada por si sola es suficiente. Por ejemplo, 50ms para hacer una predicción en un escenario listo para producción es completamente aceptable.\n",
    "\n",
    "Un cuello de botella principal es el uso de costosas funciones de activación: activación relu con SMPC es muy costosa porque utiliza comparación privada y el protocolo SecureNN. Como una ilustración, si reemplazamos relu con una activación cuadrática como es hecho en varios artículos de investigación sobre computación encriptada como CryptoNets, bajamos de 3.2s a 1.2s.\n",
    "\n",
    "Como una regla general, la idea principal es encriptar solo lo que sea necesario, y este tutorial te enseña lo simple que puede ser hacerlo."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2.2 Retropropagación con SMPC\n",
    "\n",
    "Puedes preguntarte como hacemos retropropagación y actualizaciones de gradientes aunque estemos trabajando con enteros en campos finitos. Para hacerlo, hemos desarrollado un nuevo tensor syft llamado AutogradTensor. Este tutorial lo usó intensivamente aunque puedes no haberlo visto. Veámoslo imprimiendo el peso de un modelo:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "model.fc3.bias"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y un elemento de datos:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "first_batch, input_data = 0, 0\n",
    "private_train_loader[first_batch][input_data]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Como puedes ver, el AutogradTensor está ahí. Vive entre la envoltura torch y el FixedPrecisionTensor que indica que los valores ahora están en campos finitos. La meta del AutogradTensor es almacenar el grafo computacional cuando se hagan operaciones en valores encriptados. Esto es útil porque cuando llamamos hacia atrás por la retropropagación, el AutogradTensor anula todas las funciones hacia atrás que no sean compatibles con la computación encriptada e indica como computar esos gradientes. Por ejemplo, acerca de la multiplicación que es hecha usando el truco de los triples de Beaver, no queremos diferenciar el truco más aún que diferenciando una multiplicación, debería ser muy fácil $\\partial_b (a \\cdot b) = a \\cdot \\partial b$. Así es como describimos como computar esos gradientes, por ejemplo:\n",
    "\n",
    "```python\n",
    "class MulBackward(GradFunc):\n",
    "    def __init__(self, self_, other):\n",
    "        super().__init__(self, self_, other)\n",
    "        self.self_ = self_\n",
    "        self.other = other\n",
    "\n",
    "    def gradient(self, grad):\n",
    "        grad_self_ = grad * self.other\n",
    "        grad_other = grad * self.self_ if type(self.self_) == type(self.other) else None\n",
    "        return (grad_self_, grad_other)\n",
    "```\n",
    "\n",
    "Puedes echar un vistazo a `tensors/interpreters/gradients.py` si tienes curiosidad de ver como implementamos más gradientes.\n",
    "\n",
    "En términos de grafos computacionales, significa que una copia del grafo se mantiene local y que el servidor que coordina el pase hacia adelante también provee instrucciones de como hacer el pase hacia atrás. Esta es una hipótesis completamente válida en nuestro caso."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2.3 Garantías de Seguridad\n",
    "\n",
    "Por último, daremos unos consejos sobre la seguridad que estamos consiguiendo: consideramos que los adversarios aquí son **honestos pero curiosos**: esto significa que un adversario no puede aprender nada sobre los datos corriendo este protocolo, pero un adversario malicioso podría desviarse del protocolo y por ejemplo intentar corromper las partes compartidas para sabotear la computación. La seguridad contra adversarios maliciosos en tales computaciones SMPC incluyendo comparaciones privadas todavía son un problema abierto.\n",
    "\n",
    "Además, aunque la Computación Segura Multiparte asegure que los datos de entrenamiento no fueron accesados, muchas amenazas del mundo del texto sin formato siguen presentes. Por ejemplo, a como puedas hacer peticiones al modelo (en el contexto de MLaaS), puedes obtener predicciones que posiblemente revelen información sobre los datos de entrenamiento. En particular no tienes protección contra ataques de membresía, un ataque común en servicios de aprendizaje automático donde el adversario quiere determinar si un elemento específico fue usado en el conjunto de datos. Además de esto, otros ataques como procesos de memorización desatendidos (modelos aprendiendo características específicas sobre un elemento de datos), inversión de modelos o extracción siguen siendo posibles.\n",
    "\n",
    "Una solución general que es efectiva contra muchas de las amenazas mencionadas arriba es agregar Privacidad Diferencial. Puede ser finamente combinada con la Computación Segura Multiparte y puede proveer muy interesantes garantías de seguridad. Actualmente estamos trabajando en varias implementaciones y esperamos proponer un ejemplo que combine ambas dentro de poco."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Conclusión\n",
    "\n",
    "Como pudiste ver, entrenar un modelo usando SMPC no es complicado desde el punto de vista de programación, hasta usamos objetos bastante complejos bajo el capó. Con esto en mente, ahora podrás analizar tus casos de uso para ver cuando la computación encriptada es necesaria para entrenamiento o evaluación. Si la computación encriptada es mucho más lenta en general, también puede ser usada cuidadosamente para reducir gastos generales de computación. \n",
    "\n",
    "Si lo disfrutaste y te gustaría unirte al movimiento hacia la preservación de la privacidad, propiedad descentralizada de Inteligencia Artificial y la Inteligencia Artificial de Cadena de Valores (de Datos), puedes hacerlo de las siguientes maneras:\n",
    "\n",
    "### Dale una Estrella a PySyft en GitHub\n",
    "\n",
    "¡La forma más fácil de ayudar a nuestra comunidad es guardando con una estrella los Repos! Esto ayuda a crear consciencia de las geniales herramientas que estamos construyendo.\n",
    "\n",
    "- [Guardar con Estrella a PySyft](https://github.com/OpenMined/PySyft)\n",
    "\n",
    "### ¡Únete a nuestro Slack!\n",
    "\n",
    "¡La mejor manera de estar al día con los últimos avances es unirte a nuestra comunidad! Puedes hacerlo llenando la forma en [http://slack.openmined.org](http://slack.openmined.org)\n",
    "\n",
    "### ¡Únete a un Proyecto de Programación!\n",
    "\n",
    "¡La mejor manera de contribuir a nuestra comunidad es haciéndote un contribuidor de código! Puedes ir a PySyft Github Issues en cualquier momento y filtrar por \"Projects\". Esto te mostrará todos los Tickets de alto nivel, dando un resumen de los proyectos a los que puedes unirte. Si no quieres unirte a un proyecto, pero te gustaría programar un poco, puedes buscar mini-proyectos únicos buscando en Github Issues con \"good first issue\".\n",
    "\n",
    "- [PySyft Projects](https://github.com/OpenMined/PySyft/issues?q=is%3Aopen+is%3Aissue+label%3AProject)\n",
    "- [Good First Issue Tickets](https://github.com/OpenMined/PySyft/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)\n",
    "\n",
    "### Donaciones\n",
    "\n",
    "Si no tienes tiempo para contribuir a nuestra base de código, pero quieres brindarnos tu apoyo, puedes respaldarnos en nuestro Open Collective. Todas las donaciones van hacia nuestro alojamiento web y otros gastos de la comunidad como hackatones y reuniones. \n",
    "\n",
    "[OpenMined's Open Collective Page](https://opencollective.com/openmined)"
   ]
  }
 ],
 "metadata": {
  "celltoolbar": "Tags",
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.7.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
